from contextlib import contextmanager
from utils.misc import MAXFLOAT

from cranesched.instance.window import Window


class ActivityError(Exception):
    """Class for activity-related errors."""
    pass
    
class Activity(object):
    """An activity is a wrapper for a stacking.Action object (a pickup or putdown of a list items 
    from/into a place in a warehouse) that has a set of successors and predecessors, i.e. other 
    activities that must be started after or concluded before this one. 
    An activity also has an associated satisfiability window, representing the interval during 
    which the activity should/must be completed. Please refer to the documentation of the Window
    class for more information about window constraints.
    
    The complement of an activity is the set of activities that perform the opposite of the 
    activity's effect, e.g. the complement of a pickup is a set of putdown activities that put 
    down all the items picked by in the pickup activity.
    """
    Error = ActivityError
        
    def __init__(self, action, x=0.0, y=0.0, duration=0.0, window=(-MAXFLOAT, +MAXFLOAT)):
        self.action = action
        self.id = action.id
        self.x = x
        self.y = y
        self.duration = duration
        self.window = Window(start=window[0], soft_ends=[(window[1], 1+int(action.is_release))])
        self.pred = set()
        self.succ = set()
        self.complement = set()
        self.linked = True
        
    def __repr__(self):
        return ("<%s{%s} @%s %.1ft.u. %s>" % 
                (type(self).__name__, self.action_info(), 
                 self.xy, self.duration, self.window))
        
    @property
    def xy(self):
        return (self.x, self.y)
        
    def action_info(self):
        action = self.action
        items = ", ".join(str(item) for item in action.items)
        arrow = "<<" if action.is_pickup else ">>"
        return "#%s: [%s] %s %s" % (action.id, items, arrow, action.place)
        
    # --------------------------------------------------------------------------
    # Precedence management methods
    def add_pred(self, pred, complement=False):
        """Create a precedence relation between predecessor activity 'pred' and 'self'."""
        if self._can_add_pred(pred, complement):
            self.pred.add(pred)
            pred.succ.add(self)
            if complement:
                self.complement.add(pred)
                pred.complement.add(self)
                
    def _can_add_pred(self, pred, complement):
        if pred in self.pred:
            return False
        if not self.linked or not pred.linked:
            raise Activity.Error("both activities must be linked to create a precedence relation")
        if pred is self:
            raise Activity.Error("self loop not allowed")
        # if pred in self.all_succ() or not pred.all_pred().isdisjoint(self.all_succ):
        #     raise Activity.Error("precedence cycle: unable to create precedence relation")
        if complement and self.action.is_putdown:
            if not pred.action.is_pickup:
                raise Activity.Error("complementary activity cannot be of the same type")
            if set(self.action.items).isdisjoint(pred.action.items):
                raise Activity.Error("complementary activity must have at least one item in common")
        return True
        
    def remove_pred(self, pred):
        self.pred.remove(pred)
        pred.succ.remove(self)
        self.complement.discard(pred)
        pred.complement.discard(self)
        
    def add_succ(self, succ, complement=False):
        succ.add_pred(self, complement)
        
    def remove_succ(self, succ):
        succ.remove_pred(self)
        
    @staticmethod
    def setup_sequence(sequence, complement=False):
        """Connects the activities in the given sequence. This function establishes appropriate 
        predecessor/successor relationships between activities in the sequence."""
        iterator = iter(sequence)
        try:
            prev = iterator.next()
        except StopIteration:
            pass
        else:
            for curr in iterator:
                prev.add_succ(curr, complement=complement)
                prev = curr
                
    # --------------------------------------------------------------------------
    # Methods for computing reachable sets (predecessors, successors, and connected set)
    def all_pred(self):
        """The set containing direct and indirect predecessors of this activity."""
        return Activity._reachable_set(self.pred, lambda a: a.pred)
        
    def all_succ(self):
        """The set containing direct and indirect successors of this activity."""
        return Activity._reachable_set(self.pred, lambda a: a.succ)
        
    def connected_component(self):
        """Returns all activities that may be reached from self, which includes all of direct or 
        indirect predecessors and successors and, of course, the activity itself. Note that this is 
        NOT the same as the strongly connected component. One-way connections are sufficient to 
        add an activity to the component."""
        return Activity._reachable_set(self.pred, lambda a: a.pred | a.succ)
        
    @staticmethod
    def _reachable_set(start_from, neighborhood_fnc):
        """Computes the set of all activities that are reachable (directly or indirectly) using 
        the neighborhood rule specified by 'neighborhood_fnc' (should return a set). The search 
        starts from the activities in 'start_from' (should be an iterable containing activities).
        """
        reachable = set(start_from)
        stack = list(start_from)
        while len(stack) > 0:
            activity = stack.pop()
            unseen = neighborhood_fnc(activity) - reachable
            if len(unseen) > 0:
                reachable.update(unseen)  # update our set of reachable activities
                stack.extend(unseen)      # recurse on newly seen neighbors
        return reachable
        
    # --------------------------------------------------------------------------
    # Link/unlink activities, i.e. replace references to other activities by their ids
    unlinkable_attrs = ["pred", "succ", "complement"]
    
    @contextmanager
    def unlinked(self):
        assert self.linked
        id_to_activity = {a.id: a for a in self.pred | self.succ}
        self.unlink()
        yield
        self.link(id_to_activity)
        
    def unlink(self):
        """Replace all references to other activities by their ids. This is necessary before saving 
        with pickle, otherwise the maximum recursion depth limit is easily exceeded for large 
        precedence graphs. Use link() to restore the references."""
        if self.linked:
            for attr in self.unlinkable_attrs:
                ids = {a.id for a in getattr(self, attr)}
                setattr(self, attr, ids)
            self.linked = False
            
    def link(self, id_to_activity):
        """Restore all references to other activities from the given {id: activity} mapping."""
        if not self.linked:
            for attr in self.unlinkable_attrs:
                activities = {id_to_activity[a_id] for a_id in getattr(self, attr)}
                setattr(self, attr, activities)
            self.linked = True
            
    # --------------------------------------------------------------------------
    # Constraint propagation methods (hard constraints only)
    def forward_propagate_window_start(self):
        updated = []
        self_EF = self.window.start + self.duration
        for succ in self.succ:
            succ_LS = succ.window.hard_end - succ.duration
            if succ_LS < self_EF:
                raise Activity.Error("infeasibility: successor LS < activity EF")
            if succ.window.start < self_EF:
                succ.window.start = self_EF
                updated.append(succ)
        return updated
        
    def back_propagate_window_end(self):
        updated = []
        self_LS = self.window.hard_end - self.duration
        for pred in self.pred:
            pred_EF = pred.window.start + pred.duration
            if pred_EF > self_LS:
                raise Activity.Error("infeasibility: predecessor EF > activity LS")
            if pred.window.hard_end > self_LS:
                pred.window.hard_end = self_LS
                updated.append(pred)
        return updated
        
    # --------------------------------------------------------------------------
    # Error checking (i.e. data validation)
    def check_cycles(self):
        if not self.all_pred().isdisjoint(self.all_succ()):
            raise Activity.Error("precedence cycle detected")
            
    def check_window(self):
        """Raises an exception if the activity's window size is smaller than the activity's 
        duration."""
        if self.window.size < self.duration:
            raise Activity.Error("insufficient window size")
            
